﻿// Deep Earth is a community project available under the Microsoft Public License (Ms-PL)
// Code is provided as is and with no warranty – Use at your own risk
// View the project and the latest code at http://DeepEarth.codeplex.com/

using System;
using System.Windows;
using DeepEarth.Client.MapControl.Geometries;

namespace DeepEarth.Client.MapControl.Layers
{
    /// <summary>
    /// <para>
    /// Helper class to transform Logical, Pixel and Geographic coordinates
    /// </para>
    /// <list type="bullet">
    /// <item>Logical - essentially the object is 1 unit, halfway is 0.5</item>
    /// <item>Pixel - Screen pixels, can be relative from the top left of the screen 
    /// or absolute from the top left of the object</item>
    /// <item>Geometry - Longitude, Latitude</item>
    /// </list>
    /// <example>
    /// 
    /// <code title="Transform a Pixel coordinate to a Geographical location"> 
    /// //Within our Map class we have this as a public property:
    /// 
    ///    private CoordTransform _CoordHelper;  
    ///    public CoordTransform CoordHelper
    ///    {
    ///        get
    ///        {
    ///            if (_CoordHelper == null)
    ///            {
    ///                _CoordHelper = new CoordTransform(this);
    ///            }
    ///            return _CoordHelper;
    ///       }
    ///    }
    /// 
    /// //then your code, assuming the map is "_map"
    /// Point geo = _map.CoordHelper.PixelToGeo(new Point(300,400));
    /// </code>
    /// </example>
    /// </summary>
    public class CoordTransform
    {
        private readonly Map _map;

        /// <summary>
        /// Helper class to transform Logical, Pixel and Geographic coordinates
        /// </summary>
        /// <param name="map">The instance of the current Map object to perform the conversions on</param>
        public CoordTransform(Map map)
        {
            _map = map;
        }

        #region 2D Point Transformations

        /// <summary>
        /// Convert a Geographical Point (longitude, latitude) to a logic point on the current map.
        /// </summary>
        /// <param name="geographicPoint">Geographical Point (longitude, latitude)</param>
        /// <returns>The logical Point</returns>
        public Point GeoToLogical(Location geographicPoint)
        {
            Point logical = GeographicToLogical(_map.SpatialReference, geographicPoint);
            return logical;
        }

        /// <summary>
        /// Convert a Geographical Point (longitude, latitude) to a pixel point on the current screen.
        /// </summary>
        /// <param name="geographicPoint">Geographical Point (longitude, latitude)</param>
        /// <returns>Pixel Point</returns>
        public Point GeoToPixel(Location geographicPoint)
        {
            Point logical = GeoToLogical(geographicPoint);
            Point screen = LogicalToPixel(logical);
            return screen;
        }

        /// <summary>
        /// Convert a logic point to a Geographical Point (longitude, latitude) on the current map.
        /// </summary>
        /// <param name="logicalPoint">The logical Point</param>
        /// <returns>Geographical Point (longitude, latitude)</returns>
        public Location LogicalToGeo(Point logicalPoint)
        {
            Location geographic = LogicalToGeographic(_map.SpatialReference, logicalPoint);
            return geographic;
        }

        /// <summary>
        /// Convert a logic point to a Pixel Point on the current screen at a particular zoom level.
        /// </summary>
        /// <param name="logicalPoint">The logical Point</param>
        /// <returns>Pixel Point</returns>
        public Point LogicalToPixel(Point logicalPoint)
        {
            //Note: Below is the calculation of avoid the rubber band effect caused by the MSI Animation.
            //The code below will keep the pins snapped to the correct location on MSI.Spring animations.
            //code used to be: return _Msi.LogicalToElementPoint(logicalPoint);

            double scaleFactor = _map.ActualWidth;
            var offset = new Point(-_map.LogicalOrigin.X / _map.MapViewLogicalSize.Width, -_map.LogicalOrigin.Y / _map.MapViewLogicalSize.Width);
            double zoomFactor = Math.Log(1 / _map.MapViewLogicalSize.Width, 2);

            var interimPoint = new Point(offset.X + Math.Pow(2, zoomFactor) * logicalPoint.X, offset.Y + Math.Pow(2, zoomFactor) * logicalPoint.Y);
            var pixel = new Point(interimPoint.X * scaleFactor, interimPoint.Y * scaleFactor);
            return pixel;
        }

        /// <summary>
        /// Convert a pixel point to a Geographical Point (longitude, latitude) on the current screen.
        /// </summary>
        /// <param name="screenPoint">Pixel Point</param>
        /// <returns>Geographical Point (longitude, latitude)</returns>
        public Location PixelToGeo(Point screenPoint)
        {
            Point logical = PixelToLogical(screenPoint);
            Location geographic = LogicalToGeo(logical);
            return geographic;
        }

        /// <summary>
        /// Convert a pixel point to a Logical Point on the current screen.
        /// </summary>
        /// <param name="pixel">Pixel Point</param>
        /// <returns>The logical Point</returns>
        public Point PixelToLogical(Point pixel)
        {
            var offset = _map.LogicalOrigin;
            var pixelFactorX = _map.MapViewLogicalSize.Width / _map.MapViewPixelSize.Width;
            var pixelFactorY = _map.MapViewLogicalSize.Height / _map.MapViewPixelSize.Height;
            var logical = new Point((pixel.X * pixelFactorX) + offset.X, (pixel.Y * pixelFactorY) + offset.Y);

            return logical;
        }

        internal Point PixelToLogicalIncRotation(Point pixel)
        {
            if (_map.IsMapLoaded)
            {
                //consider rotation
                return PixelToLogical(RotatePixelbyMapRotation(pixel));
            }
            return new Point(0.5, 0.5);
        }

        /// <summary>
        /// Rotates a Pixel coordinate by the current map rotation
        /// </summary>
        /// <param name="point">Pixel Point</param>
        /// <returns>Pixel Point</returns>
        public Point RotatePixelbyMapRotation(Point point)
        {
            double a = (Math.PI / 180) * _map.RotationAngle;
            double x = point.X * Math.Cos(a) + point.Y * Math.Sin(a);
            double y = -point.X * Math.Sin(a) + point.Y * Math.Cos(a);
            return new Point(x, y);
        }

        #endregion

        #region 3D Point Transformations

        /// <summary>
        /// Converts a pixel point to a Geographical Point (longitude, latitude) at the supplied Zoom Level.
        /// </summary>
        /// <param name="screenPoint">Pixel Point</param>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <returns>Geographical Point (longitude, latitude)</returns>
        public Location PixelToGeoAtZoom(Point screenPoint, int zoomLevel)
        {
            const double projectionOffset = Constants.PROJECTION_OFFSET;
            double c = Constants.EARTH_CIRCUMFERENCE / ((1 << zoomLevel) * TileSize.Width);
            double f = screenPoint.X * c - projectionOffset;
            double g = projectionOffset - screenPoint.Y * c;

            double latitude = RadiansToDegrees(Math.PI / 2 - 2 * Math.Atan(Math.Exp(-g / Constants.EARTH_RADIUS)));
            double longitude = RadiansToDegrees(f / Constants.EARTH_RADIUS);

            // var point = Clip(new Point(longitude, latitude));

            var location = Clip(new Location { Longitude = longitude, Latitude = latitude });

            return location;
        }

        /// <summary>
        /// Convert a Geographical Point (longitude, latitude) to a pixel point at the supplied Zoom Level.
        /// </summary>
        /// <param name="geographicPoint">Geographical Point (longitude, latitude)</param>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <returns>Pixel Point</returns>
        public Point GeoToPixelAtZoom(Location geographicPoint, int zoomLevel)
        {
            const double projectionOffset = Constants.PROJECTION_OFFSET;
            double e = Math.Sin(DegreesToRadians(geographicPoint.Latitude));
            double g = Constants.EARTH_RADIUS * DegreesToRadians(geographicPoint.Longitude);
            double h = Constants.EARTH_RADIUS / 2 * Math.Log((1 + e) / (1 - e));
            double c = Constants.EARTH_CIRCUMFERENCE / ((1 << zoomLevel) * TileSize.Width);

            double pixelX = (projectionOffset + g) / c;
            double pixelY = (projectionOffset - h) / c;

            return new Point(pixelX, pixelY);
        }

        #endregion

        #region LogicalView to ZoomLevel Conversions

        /// <summary>
        /// Gets the Logical Viewport for the supplied zoomlevel maintaining the centre point of the map
        /// </summary>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <returns>The Logical Viewport</returns>
        public Size ZoomLevelToLogicalView(double zoomLevel)
        {
            //If the zoom is invalid, do nothing. 
            Map map = _map;
            var bl = map.BaseLayer.Source;
            var targetWidth = map.MapViewPixelSize.Width / bl.TileSize.Width / Math.Pow(2.0, zoomLevel);
            var targetHeight = targetWidth * (map.MapViewPixelSize.Height / map.MapViewPixelSize.Width);
            var viewPort = new Size(targetWidth, targetHeight);

            return viewPort;
        }

        /// <summary>
        /// Gets the Zoomlevel required to fit the supplied Logical Viewport
        /// </summary>
        /// <param name="viewPort">Logical Viewport</param>
        /// <returns>The zoom level to fit</returns>
        public double LogicalViewToZoomLevel(Size viewPort)
        {
            Map map = _map;
            TileSource baseLayer = map.BaseLayer.Source;
            double targetZoom = Math.Log(map.MapViewPixelSize.Width / baseLayer.TileSize.Width / viewPort.Width, 2);
            return targetZoom;
        }

        #endregion

        #region GetScaleAtZoomLevel

        /// <summary>
        /// Gets the scale of the map at the supplied zoom level at the equator in the unit provided
        /// </summary>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <param name="unit">The unit of measure to use</param>
        /// <returns>The scale value</returns>
        public double GetScaleAtZoomLevel(double zoomLevel, Unit unit)
        {
            return GetScaleAtZoomLevel(0, zoomLevel, unit);
        }

        /// <summary>
        /// Gets the scale of the map at the supplied zoom level at the supplied Geographical Point in the unit provided
        /// </summary>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <param name="geographicPoint">Geographical Point (longitude, latitude)</param>
        /// <param name="unit">The unit of measure to use</param>
        /// <returns>The scale value</returns>
        public double GetScaleAtZoomLevel(double zoomLevel, Location geographicPoint, Unit unit)
        {
            return GetScaleAtZoomLevel(geographicPoint.Latitude, zoomLevel, unit);
        }

        /// <summary>
        /// Gets the scale of the map at the supplied zoom level at the supplied Latitude in the unit provided
        /// </summary>
        /// <param name="latitude">The Line of Latitude to calculate at</param>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <param name="unit">The unit of measure to use</param>
        /// <returns>The scale value</returns>
        public double GetScaleAtZoomLevel(double latitude, double zoomLevel, Unit unit)
        {
            return GetScaleAtZoomLevel(latitude, zoomLevel, GetScreenDpi(), unit);
        }

        /// <summary>
        /// Gets the scale of the map at the supplied zoom level at the supplied Latitude 
        /// at the supplied Sceen DPI in the unit provided
        /// </summary>
        /// <param name="latitude">The Line of Latitude to calculate at</param>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <param name="screenDpi">The Dots Per Inch of the current Screen</param>
        /// <param name="unit">The unit of measure to use</param>
        /// <returns>The scale value</returns>
        public double GetScaleAtZoomLevel(double latitude, double zoomLevel, double screenDpi, Unit unit)
        {
            double resolution = GetResolutionAtZoomLevel(latitude, zoomLevel, unit);
            double scale = resolution * screenDpi;

            double unitPerInch = ConvertUnit(1, Unit.Inch, unit);
            return scale / unitPerInch;
        }

        private static double GetScreenDpi()
        {
            //if (HtmlPage.IsEnabled)
            //{
            //    var screen = HtmlPage.Window.GetProperty("screen") as ScriptObject;
            //    if (screen != null)
            //    {
            //        object o = screen.GetProperty("deviceYDPI");
            //        if (o != null)
            //        {
            //            return (double) o;
            //        }
            //    }
            //}
            // return 96;

            //TODO: find solution to get screen DPI, .net noobie

            return Application.Current.Host.Content.ZoomFactor;
        }

        #endregion

        #region GetResolutionAtZoomLevel

        /// <summary>
        /// Gets the resolution of a pixel at the equator at the supplied zoom level in the unit provided
        /// </summary>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <param name="unit">The unit of measure to use</param>
        /// <returns>The resolution value</returns>
        public double GetResolutionAtZoomLevel(double zoomLevel, Unit unit)
        {
            return GetResolutionAtZoomLevel(0, zoomLevel, unit);
        }

        /// <summary>
        /// Gets the resolution of a pixel at the supplied Geographical Point (longitude, latitude) 
        /// at the supplied zoom level in the unit provided
        /// </summary>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <param name="geographicPoint">Geographical Point (longitude, latitude)</param>
        /// <param name="unit">The unit of measure to use</param>
        /// <returns>The resolution value</returns>
        public double GetResolutionAtZoomLevel(double zoomLevel, Location geographicPoint, Unit unit)
        {
            return GetResolutionAtZoomLevel(geographicPoint.Latitude, zoomLevel, unit);
        }

        /// <summary>
        /// Gets the resolution of a pixel at the supplied latitude at the supplied zoom level in the unit provided
        /// </summary>
        /// <param name="latitude">The Line of Latitude to calculate at</param>
        /// <param name="zoomLevel">The zoom level of the map</param>
        /// <param name="unit">The unit of measure to use</param>
        /// <returns>The resolution value</returns>
        public double GetResolutionAtZoomLevel(double latitude, double zoomLevel, Unit unit)
        {
            double mapWidth = _map.BaseLayer.Source.MapSizeAtTileLevel(Convert.ToInt32(zoomLevel)).Width;
            double earthRadius = ConvertUnit(Constants.EARTH_RADIUS, Unit.Meter, unit);
            latitude = Clip(latitude, MinLatitude, MaxLatitude);
            return Math.Cos(latitude * Math.PI / 180) * 2 * Math.PI * earthRadius / mapWidth;
        }

        #endregion

        #region Static Math Calculations

        /// <summary>
        /// Converts a unit of measure to another unit of measure
        /// </summary>
        /// <param name="sourceValue">The value to convert</param>
        /// <param name="sourceUnit">The source unit of measure</param>
        /// <param name="destinationUnit">The unit of measure to convert to</param>
        /// <returns>The converted unit value</returns>
        public static double ConvertUnit(double sourceValue, Unit sourceUnit, Unit destinationUnit)
        {
            if (sourceUnit != destinationUnit)
            {
                switch (sourceUnit)
                {
                    case Unit.Kilometer:
                        sourceValue *= 1000;
                        break;

                    case Unit.Inch:
                        sourceValue *= Constants.INCH_TO_METER;
                        break;

                    case Unit.Mile:
                        sourceValue *= Constants.MILE_TO_METER;
                        break;
                }

                switch (destinationUnit)
                {
                    case Unit.Kilometer:
                        sourceValue /= 1000;
                        break;

                    case Unit.Inch:
                        sourceValue *= Constants.METER_TO_INCH;
                        break;

                    case Unit.Mile:
                        sourceValue *= Constants.METER_TO_MILE;
                        break;
                }
            }

            return sourceValue;
        }

        /// <summary>
        /// Converts Radians to Degrees
        /// </summary>
        /// <param name="r">Value in Radians</param>
        /// <returns>Value in Degrees</returns>
        public static double RadiansToDegrees(double r)
        {
            return (180 / Math.PI) * r;
        }

        /// <summary>
        /// Converts Degrees to Radians
        /// </summary>
        /// <param name="d">Value in Degrees</param>
        /// <returns>Value in Radians</returns>
        public static double DegreesToRadians(double d)
        {
            return (Math.PI / 180) * d;
        }

        /// <summary>
        /// Calculate distance between two coordinates/Locations using the Haversine formula
        /// </summary>
        /// <param name="location1">Start Location</param>
        /// <param name="location2">Finish Location</param>
        /// <returns>Distance Between coordinates in Km's</returns>
        public static double DistanceBetweenLocations(Location location1, Location location2)
        {
            //Convert input values to radians
            var lat1 = DegreesToRadians(location1.Latitude);
            var long1 = DegreesToRadians(location1.Longitude);
            var lat2 = DegreesToRadians(location2.Latitude);
            var long2 = DegreesToRadians(location2.Longitude);

            const double earthRadius = Constants.EARTH_RADIUS / 1000;
            var deltaLat = lat2 - lat1;
            var deltaLong = long2 - long1;

            var a = Math.Sin(deltaLat / 2.0) * Math.Sin(deltaLat / 2.0)
                + Math.Cos(lat1) * Math.Cos(lat2) * Math.Sin(deltaLong / 2.0) * Math.Sin(deltaLong / 2.0);

            var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));

            var distance = earthRadius * c;

            return distance;
        }

        /// <summary>
        /// Calculate Midpoint/location between two Locations
        /// </summary>
        /// <param name="location1">Start Location</param>
        /// <param name="location2">Finish Location</param>
        /// <returns>Midpoint Location</returns>
        public static Location MidpointLocation(Location location1, Location location2)
        {
            //Convert input values to radians
            var lat1 = DegreesToRadians(location1.Latitude);
            var long1 = DegreesToRadians(location1.Longitude);
            var lat2 = DegreesToRadians(location2.Latitude);
            var long2 = DegreesToRadians(location2.Longitude);

            var deltaLat = lat2 - lat1;
            var deltaLong = long2 - long1;

            var bx = Math.Cos(lat2) * Math.Cos(deltaLong);
            var by = Math.Cos(lat2) * Math.Sin(deltaLong);

            var midpointLat = DegreesToRadians(Math.Atan2(Math.Sin(lat1) + Math.Sin(lat2),
                Math.Sqrt((Math.Cos(lat1) + bx) * (Math.Cos(lat1) + bx) + by * by)));

            var midpointLong = DegreesToRadians(long1 + Math.Atan2(by, Math.Cos(lat1) + bx));

            return new Location { Latitude = midpointLat, Longitude = midpointLong };
        }

        /// <summary>
        /// Calculate the Bearing/Direction in Degrees from Start to Finish Point/Location
        /// </summary> 
        /// <param name="location1">Start Location</param>
        /// <param name="location2">Finish Location</param>
        /// <returns>Bearing as a double</returns>
        public static double GetBearing(Location location1, Location location2)
        {
            //Convert input values to radians
            var lat1 = DegreesToRadians(location1.Latitude);
            var long1 = DegreesToRadians(location1.Longitude);
            var lat2 = DegreesToRadians(location2.Latitude);
            var long2 = DegreesToRadians(location2.Longitude);

            var deltaLong = long2 - long1;

            var y = Math.Sin(deltaLong) * Math.Cos(lat2);
            var x = Math.Cos(lat1) * Math.Sin(lat2) - Math.Sin(lat1) * Math.Cos(lat2) * Math.Cos(deltaLong);

            var bearing = Math.Atan2(y, x);

            return ConvertToBearing(RadiansToDegrees(bearing));
        }

        /// <summary>
        /// Convert Degrees To Bearing
        /// </summary>
        /// <param name="deg">Degrees</param>
        /// <returns></returns>
        public static double ConvertToBearing(double deg)
        {
            return (deg + 360) % 360;
        }

        /// <summary>
        /// Reduces the number to fit within the supplied bounds
        /// </summary>
        /// <param name="n">The number to clip</param>
        /// <param name="minValue">The minimal allowed value</param>
        /// <param name="maxValue">The maximum allowed value</param>
        /// <returns>The clipped value</returns>
        public static double Clip(double n, double minValue, double maxValue)
        {
            return Math.Min(Math.Max(n, minValue), maxValue);
        }

        /// <summary>
        /// Reduces the Geographical Location to fit within the bounds of the Earth.
        /// </summary>
        /// <param name="geographicPoint">Geographical Location (longitude, latitude)</param>
        /// <returns>Clipped Geographical Location (longitude, latitude)</returns>
        public static Location Clip(Location geographicPoint)
        {
            geographicPoint.Longitude = Clip(geographicPoint.Longitude, Constants.EARTH_MIN_LONGITUDE, Constants.EARTH_MAX_LONGITUDE);
            geographicPoint.Latitude = Clip(geographicPoint.Latitude, Constants.EARTH_MIN_LATITUDE, Constants.EARTH_MAX_LATITUDE);
            return geographicPoint;
        }

        #endregion

        #region BaseLayer accessors


        private double MinLatitude
        {
            get { return _map.BaseLayer.Source.MinLatitude; }
        }

        private double MaxLatitude
        {
            get { return _map.BaseLayer.Source.MaxLatitude; }
        }


        private Size TileSize
        {
            get { return _map.BaseLayer.Source.TileSize; }
        }

        #endregion

        #region General Coordinate Transformations
        /*
         * Future work:  Add transformation support from one datum & projection to another.
         * This will become very important with upcoming support for geometric shapes and other,
         * non-WGS WMS data providers.  Currently, all sources are in WGS, so default to Virtual
         * Earth's WGS Mercator Projection
         */

        /// <summary>
        /// Convert a logic point to a Geographical Point (longitude, latitude) for the specified spatial reference.
        /// </summary>
        /// <param name="srs">The spatial reference to use</param>
        /// <param name="logicalPoint">Logical Point</param>
        /// <returns>Geographical Point (longitude, latitude)</returns>
        public Location LogicalToGeographic(ISpatialReference srs, Point logicalPoint)
        {
            return srs.LogicalToGeographic(logicalPoint);
        }

        /// <summary>
        /// Convert a Geographical Point (longitude, latitude) to a logic point for the specified spatial reference.
        /// </summary>
        /// <param name="srs">The spatial reference to use</param>
        /// <param name="geographicPoint">Geographical Point (longitude, latitude)</param>
        /// <returns>Logical Point</returns>
        public Point GeographicToLogical(ISpatialReference srs, Location geographicPoint)
        {
            return srs.GeographicToLogical(geographicPoint);
        }

        #endregion General Coordinate Transformations

    }
}